import QueuingEvented from '../core/QueuingEvented';
import { RouteConfig, History, OutletContext, Params, RouterInterface, Route, RouterOptions } from './interfaces';
import { HashHistory } from './history/HashHistory';
import { EventObject } from '../core/Evented';

const PARAM = '__PARAM__';

const paramRegExp = new RegExp(/^{.+}$/);

interface RouteWrapper {
	route: Route;
	segments: string[];
	parent?: RouteWrapper;
	type?: string;
	params: Params;
}

export interface NavEvent extends EventObject<string> {
	outlet?: string;
	context?: OutletContext;
}

export interface OutletEvent extends EventObject<string> {
	outlet: OutletContext;
	action: 'enter' | 'exit';
}

const ROUTE_SEGMENT_SCORE = 7;
const DYNAMIC_SEGMENT_PENALTY = 2;

function matchingParams({ params: previousParams }: OutletContext, { params }: OutletContext) {
	const matching = Object.keys(previousParams).every((key) => previousParams[key] === params[key]);
	if (!matching) {
		return false;
	}
	return Object.keys(params).every((key) => previousParams[key] === params[key]);
}

export class Router extends QueuingEvented<{ nav: NavEvent; outlet: OutletEvent }> implements RouterInterface {
	private _routes: Route[] = [];
	private _outletMap: { [index: string]: Route } = Object.create(null);
	private _matchedOutlets: { [index: string]: OutletContext } = Object.create(null);
	private _currentParams: Params = {};
	private _currentQueryParams: Params = {};
	private _defaultOutlet: string | undefined;
	private _history: History;

	constructor(config: RouteConfig[], options: RouterOptions = {}) {
		super();
		const { HistoryManager = HashHistory, base, window } = options;
		this._register(config);
		this._history = new HistoryManager({ onChange: this._onChange, base, window });
		if (this._matchedOutlets.errorOutlet && this._defaultOutlet) {
			const path = this.link(this._defaultOutlet);
			if (path) {
				this.setPath(path);
			}
		}
	}

	/**
	 * Sets the path against the registered history manager
	 *
	 * @param path The path to set on the history manager
	 */
	public setPath(path: string): void {
		this._history.set(path);
	}

	/**
	 * Generate a link for a given outlet identifier and optional params.
	 *
	 * @param outlet The outlet to generate a link for
	 * @param params Optional Params for the generated link
	 */
	public link(outlet: string, params: Params = {}): string | undefined {
		const { _outletMap, _currentParams, _currentQueryParams } = this;
		let route = _outletMap[outlet];
		if (route === undefined) {
			return;
		}

		let linkPath: string | undefined = route.fullPath;
		if (route.fullQueryParams.length > 0) {
			let queryString = route.fullQueryParams.reduce((queryParamString, param, index) => {
				if (index > 0) {
					return `${queryParamString}&${param}={${param}}`;
				}
				return `?${param}={${param}}`;
			}, '');
			linkPath = `${linkPath}${queryString}`;
		}
		params = { ...route.defaultParams, ..._currentQueryParams, ..._currentParams, ...params };

		if (Object.keys(params).length === 0 && route.fullParams.length > 0) {
			return undefined;
		}

		const fullParams = [...route.fullParams, ...route.fullQueryParams];
		for (let i = 0; i < fullParams.length; i++) {
			const param = fullParams[i];
			if (params[param]) {
				linkPath = linkPath.replace(`{${param}}`, params[param]);
			} else {
				return undefined;
			}
		}
		return this._history.prefix(linkPath);
	}

	/**
	 * Returns the outlet context for the outlet identifier if one has been matched
	 *
	 * @param outletIdentifier The outlet identifer
	 */
	public getOutlet(outletIdentifier: string): OutletContext | undefined {
		return this._matchedOutlets[outletIdentifier];
	}

	/**
	 * Returns all the params for the current matched outlets
	 */
	public get currentParams() {
		return this._currentParams;
	}

	/**
	 * Strips the leading slash on a path if one exists
	 *
	 * @param path The path to strip a leading slash
	 */
	private _stripLeadingSlash(path: string): string {
		if (path[0] === '/') {
			return path.slice(1);
		}
		return path;
	}

	/**
	 * Registers the routing configuration
	 *
	 * @param config The configuration
	 * @param routes The routes
	 * @param parentRoute The parent route
	 */
	private _register(config: RouteConfig[], routes?: Route[], parentRoute?: Route): void {
		routes = routes ? routes : this._routes;
		for (let i = 0; i < config.length; i++) {
			let { path, outlet, children, defaultRoute = false, defaultParams = {} } = config[i];
			let [parsedPath, queryParamString] = path.split('?');
			let queryParams: string[] = [];
			parsedPath = this._stripLeadingSlash(parsedPath);

			const segments: string[] = parsedPath.split('/');
			const route: Route = {
				params: [],
				outlet,
				path: parsedPath,
				segments,
				defaultParams: parentRoute ? { ...parentRoute.defaultParams, ...defaultParams } : defaultParams,
				children: [],
				fullPath: parentRoute ? `${parentRoute.fullPath}/${parsedPath}` : parsedPath,
				fullParams: [],
				fullQueryParams: [],
				score: parentRoute ? parentRoute.score : 0
			};
			if (defaultRoute) {
				this._defaultOutlet = outlet;
			}
			for (let i = 0; i < segments.length; i++) {
				const segment = segments[i];
				route.score += ROUTE_SEGMENT_SCORE;
				if (paramRegExp.test(segment)) {
					route.score -= DYNAMIC_SEGMENT_PENALTY;
					route.params.push(segment.replace('{', '').replace('}', ''));
					segments[i] = PARAM;
				}
			}
			if (queryParamString) {
				queryParams = queryParamString.split('&').map((queryParam) => {
					return queryParam.replace('{', '').replace('}', '');
				});
			}
			route.fullQueryParams = parentRoute ? [...parentRoute.fullQueryParams, ...queryParams] : queryParams;

			route.fullParams = parentRoute ? [...parentRoute.fullParams, ...route.params] : route.params;

			if (children && children.length > 0) {
				this._register(children, route.children, route);
			}
			this._outletMap[outlet] = route;
			routes.push(route);
		}
	}

	/**
	 * Returns an object of query params
	 *
	 * @param queryParamString The string of query params, e.g `paramOne=one&paramTwo=two`
	 */
	private _getQueryParams(queryParamString?: string): { [index: string]: string } {
		const queryParams: { [index: string]: string } = {};
		if (queryParamString) {
			const queryParameters = queryParamString.split('&');
			for (let i = 0; i < queryParameters.length; i++) {
				const [key, value] = queryParameters[i].split('=');
				queryParams[key] = value;
			}
		}
		return queryParams;
	}

	/**
	 * Called on change of the route by the the registered history manager. Matches the path against
	 * the registered outlets.
	 *
	 * @param requestedPath The path of the requested route
	 */
	private _onChange = (requestedPath: string): void => {
		requestedPath = this._stripLeadingSlash(requestedPath);
		const previousMatchedOutlets = this._matchedOutlets;
		this._matchedOutlets = Object.create(null);
		const [path, queryParamString] = requestedPath.split('?');
		this._currentQueryParams = this._getQueryParams(queryParamString);
		const segments = path.split('/');
		let routeConfigs: RouteWrapper[] = this._routes.map((route) => ({
			route,
			segments: [...segments],
			parent: undefined,
			params: {}
		}));
		let routeConfig: RouteWrapper | undefined;
		let matchedRoutes: RouteWrapper[] = [];
		while ((routeConfig = routeConfigs.pop())) {
			const { route, parent, segments, params } = routeConfig;
			let segmentIndex = 0;
			let type = 'index';
			let paramIndex = 0;
			let routeMatch = true;
			if (segments.length < route.segments.length) {
				routeMatch = false;
			} else {
				while (segments.length > 0) {
					if (route.segments[segmentIndex] === undefined) {
						type = 'partial';
						break;
					}
					const segment = segments.shift()!;
					if (route.segments[segmentIndex] === PARAM) {
						params[route.params[paramIndex++]] = segment;
						this._currentParams = { ...this._currentParams, ...params };
					} else if (route.segments[segmentIndex] !== segment) {
						routeMatch = false;
						break;
					}
					segmentIndex++;
				}
			}

			if (routeMatch) {
				routeConfig.type = type;
				matchedRoutes.push({ route, parent, type, params, segments: [] });
				if (segments.length) {
					routeConfigs = [
						...routeConfigs,
						...route.children.map((childRoute) => ({
							route: childRoute,
							segments: [...segments],
							parent: routeConfig,
							type,
							params: { ...params }
						}))
					];
				}
			}
		}

		let matchedOutletName: string | undefined = undefined;
		let matchedRoute: any = matchedRoutes.reduce((match: any, matchedRoute: any) => {
			if (!match) {
				return matchedRoute;
			}
			if (match.route.score > matchedRoute.route.score) {
				return match;
			}
			return matchedRoute;
		}, undefined);

		if (matchedRoute) {
			if (matchedRoute.type === 'partial') {
				matchedRoute.type = 'error';
			}
			matchedOutletName = matchedRoute.route.outlet;
			while (matchedRoute) {
				let { type, params, parent, route } = matchedRoute;
				const matchedOutlet = {
					id: route.outlet,
					queryParams: this._currentQueryParams,
					params,
					type,
					isError: () => type === 'error',
					isExact: () => type === 'index'
				};
				const previousMatchedOutlet = previousMatchedOutlets[route.outlet];
				this._matchedOutlets[route.outlet] = matchedOutlet;
				if (!previousMatchedOutlet || !matchingParams(previousMatchedOutlet, matchedOutlet)) {
					this.emit({ type: 'outlet', outlet: matchedOutlet, action: 'enter' });
				}
				matchedRoute = parent;
			}
		} else {
			this._matchedOutlets.errorOutlet = {
				id: 'errorOutlet',
				queryParams: {},
				params: {},
				isError: () => true,
				isExact: () => false,
				type: 'error'
			};
		}

		const previousMatchedOutletKeys = Object.keys(previousMatchedOutlets);
		for (let i = 0; i < previousMatchedOutletKeys.length; i++) {
			const key = previousMatchedOutletKeys[i];
			const matchedOutlet = this._matchedOutlets[key];
			if (!matchedOutlet || !matchingParams(previousMatchedOutlets[key], matchedOutlet)) {
				this.emit({ type: 'outlet', outlet: previousMatchedOutlets[key], action: 'exit' });
			}
		}
		this.emit({
			type: 'nav',
			outlet: matchedOutletName,
			context: matchedOutletName ? this._matchedOutlets[matchedOutletName] : undefined
		});
	};
}

export default Router;
